﻿' Arranges nodes based on relationships. Contains a series or rows (DiagramRow), 
' each row contains a series of groups (DiagramGroup), and each group contains a 
' series of nodes (DiagramNode).
' 
'Contains a list of connections. Each connection describes where the node
' is located in the diagram and type of connection. The lines are draw
' during OnRender.
' 
' Diagram is responsible for managing the rows. The logic that populates the rows
' and understand all of the relationships is contained in DiagramLogic.
Imports System
Imports System.Collections.Generic
Imports System.Collections.ObjectModel
Imports System.Windows.Controls
Imports System.Windows
Imports System.Windows.Media
Imports System.Windows.Media.Animation
Imports System.Windows.Input
Imports System.Windows.Threading

''' <summary>
''' Diagram that lays out and displays the nodes.
''' </summary>
<System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses")> _
Public Class Diagram
    Inherits FrameworkElement

#Region " fields "

    'Duration to pause before displaying new nodes.
    Public Shared AnimationPauseDuration As Double = 600

    ' Duration for nodes to fade in when the diagram is repopulated.
    Public Shared NodeFadeInDuration As Double = 500

    ' Duration for the new Item animation.
    Public Shared NewItemAnimationDuration As Double = 250

    'Stop adding new rows when the number of nodes exceeds the max node limit.
    Public Shared MaximumNodes As Integer = 50

    ' Group space.
    Public Shared PrimaryRowGroupSpace As Double = 20
    Public Shared ChildRowGroupSpace As Double = 20
    Public Shared ParentRowGroupSpace As Double = 40

    ' Amount of space between each row.
    Public Shared RowSpace As Double = 40

    ' Scale multiplier for spouse and siblings.
    Public Shared RelatedMultiplier As Double = 0.8

    ' Scale multiplier for each future generation row.
    Public Shared GenerationMultiplier As Double = 0.9

    ' List of rows in the diagram. Each row contains groups, and each group contains nodes.
    Private m_rows As New List(Of DiagramRow)

    ' Populates the rows with nodes.
    Private m_logic As DiagramLogic

    ' Size of the diagram. Used to layout all of the nodes before the
    ' control gets an actual size.
    Private m_totalSize As New Size(0, 0)

    ' Zoom level of the diagram.
    Private m_scale As Double = 1.0

    ' Bounding area of the selected node, the selected node is the 
    ' non-primary node that is selected, and will become the primary node.
    Private m_selectedNodeBounds As Rect = Rect.Empty

    ' Flag if currently populating or not. Necessary since diagram populate 
    ' contains several parts and animations, request to update the diagram
    ' are ignored when Me flag is set.
    Private m_populating As Boolean

    ' The Node that has been added to the diagram.
    Private m_newItem As Item

    ' Timer used with the repopulating animation.
    Private m_animationTimer As DispatcherTimer = New DispatcherTimer()

#If DEBUG Then
    ' Flag if the row and group borders should be drawn.
    Private m_displayBorder As Boolean = False
#End If

#End Region

#Region " events "

    Public Event DiagramUpdated As EventHandler

    Private Sub OnDiagramUpdated()
        If Not DiagramUpdatedEvent Is Nothing Then
            RaiseEvent DiagramUpdated(Me, EventArgs.Empty)
        End If
    End Sub

    Public Event DiagramPopulated As EventHandler
    Private Sub OnDiagramPopulated()
        If Not DiagramPopulatedEvent Is Nothing Then
            RaiseEvent DiagramPopulated(Me, EventArgs.Empty)
        End If
    End Sub


#End Region

#Region " properties "

    ''' <summary>
    ''' List of groups in the row.
    ''' </summary>
    Public ReadOnly Property Rows() As ReadOnlyCollection(Of DiagramRow)
        Get
            Return New ReadOnlyCollection(Of DiagramRow)(m_rows)
        End Get
    End Property

    ''' <summary>
    ''' Gets or sets the zoom level of the diagram.
    ''' </summary>
    Public Property Scale() As Double
        Get
            Return Me.m_scale
        End Get
        Set(ByVal value As Double)
            If Not Me.m_scale = value Then
                m_scale = value
                Me.LayoutTransform = New ScaleTransform(Scale, Scale)
            End If
        End Set
    End Property

    ''' <summary>
    ''' Gets the bounding area (relative to the diagram) of the primary node.
    ''' </summary>
    Public ReadOnly Property PrimaryNodeBounds() As Rect
        Get
            Return m_logic.GetNodeBounds(m_logic.ItemWrapperItems.Current)
        End Get
    End Property

    ''' <summary>
    ''' Gets the bounding area (relative to the diagram) of the selected node.
    ''' The selected node is the non-primary node that was previously selected
    ''' to be the primary node.
    ''' </summary>
    Public ReadOnly Property SelectedNodeBounds() As Rect
        Get
            Return SelectedNodeBounds
        End Get
    End Property

    ''' <summary>
    ''' Gets the number of nodes in the diagram.
    ''' </summary>
    Public ReadOnly Property NodeCount() As Integer
        Get
            Return m_logic.ItemLookup.Count
        End Get
    End Property


#End Region

    Public Sub New()

        ' Init the diagram logic, which handles all of the layout logic.
        m_logic = New DiagramLogic
        m_logic.NodeClickHandler = New RoutedEventHandler(AddressOf OnNodeClick)

        ' Can have an empty Items collection when in design tools such as Blend.
        If Not m_logic.ItemWrapperItems Is Nothing Then
            AddHandler m_logic.ItemWrapperItems.ContentChanged, New EventHandler(Of ContentChangedEventArgs)(AddressOf OnFamilyContentChanged)
            AddHandler m_logic.ItemWrapperItems.CurrentChanged, New EventHandler(AddressOf OnFamilyCurrentChanged)
        End If


    End Sub

#Region " layout "

    Protected Overrides Sub OnInitialized(ByVal e As EventArgs)
#If DEBUG Then
        ' Context menu so can display row and group borders.
        Me.ContextMenu = New ContextMenu()
        Dim item As MenuItem = New MenuItem()
        Me.ContextMenu.Items.Add(item)
        item.Header = "Show Diagram Outline"
        AddHandler item.Click, New RoutedEventHandler(AddressOf OnToggleBorderClick)
        item.Foreground = SystemColors.MenuTextBrush
        item.Background = SystemColors.MenuBrush
#End If

        UpdateDiagram()
        MyBase.OnInitialized(e)
    End Sub

    Protected Overrides ReadOnly Property VisualChildrenCount() As Integer
        Get
            ' Return the number of rows.
            Return m_rows.Count
        End Get
    End Property

    Protected Overrides Function GetVisualChild(ByVal index As Integer) As Visual
        ' Return the requested row.
        Return m_rows(index)
    End Function

    Protected Overrides Function MeasureOverride(ByVal availableSize As Size) As Size
        ' Let each row determine how large they want to be.
        Dim size As New Size(Double.PositiveInfinity, Double.PositiveInfinity)

        For Each row In m_rows
            row.Measure(size)
        Next
        ' Return the total size of the diagram.
        Return ArrangeRows(False)
    End Function


    Protected Overrides Function ArrangeOverride(ByVal finalSize As Size) As Size
        ' Arrange the rows in the diagram, return the total size.
        Return ArrangeRows(True)
    End Function

    ''' <summary>
    ''' Arrange the rows in the diagram, return the total size.
    ''' </summary>
    Private Function ArrangeRows(ByVal arrange As Boolean) As Size
        ' Location of the row.
        Dim pos As Double = 0

        ' Bounding area of the row.
        Dim bounds As New Rect()

        ' Total size of the diagram.
        Dim size As New Size(0, 0)

        For Each row In m_rows
            ' Row location, center the row horizontaly.
            bounds.Y = pos
            bounds.X = CDbl(IIf(m_totalSize.Width = 0, 0, bounds.X = (m_totalSize.Width - row.DesiredSize.Width) / 2))

            ' Row Size.
            bounds.Width = row.DesiredSize.Width
            bounds.Height = row.DesiredSize.Height

            ' Arrange the row, save the location.
            If arrange Then
                row.Arrange(bounds)
                row.Location = bounds.TopLeft
            End If
            ' Update the size of the diagram.
            size.Width = Math.Max(size.Width, bounds.Width)
            size.Height = pos + row.DesiredSize.Height
            pos += bounds.Height
        Next
        ' Store the size, Me is necessary so the diagram
        ' can be laid out without a valid Width property.
        m_totalSize = size
        Return size
    End Function



#End Region

    ''' <summary>
    ''' Draw the connector lines at a lower level (OnRender) instead
    ''' of creating visual tree objects.
    ''' </summary>
    Protected Overrides Sub OnRender(ByVal drawingContext As DrawingContext)
#If DEBUG Then
        If m_displayBorder Then
            ' Draws borders around the rows and groups.
            For Each row In m_rows
                ' Display row border.
                Dim bounds As Rect = New Rect(row.Location, row.DesiredSize)
                drawingContext.DrawRectangle(Nothing, New Pen(Brushes.DarkKhaki, 1), bounds)

                For Each group In row.Groups
                    ' Display group border.
                    bounds = New Rect(group.Location, group.DesiredSize)
                    bounds.Offset(row.Location.X, row.Location.Y)
                    bounds.Inflate(-1, -1)
                    drawingContext.DrawRectangle(Nothing, New Pen(Brushes.Gray, 1), bounds)
                Next

            Next
        End If

#End If

        ' Draw child connectors first, so marriage information appears on top.
        For Each connector In m_logic.Connections
            If connector.IsChildConnector Then
                connector.Draw(drawingContext)
            End If

            ' Draw all other non-child connectors.
            For Each connector2 In m_logic.Connections
                If Not connector2.IsChildConnector Then
                    connector2.Draw(drawingContext)
                End If
            Next
        Next

    End Sub

#If DEBUG Then
    Sub OnToggleBorderClick(ByVal sender As Object, ByVal e As RoutedEventArgs)
        ' Display or hide the row and group borders.
        m_displayBorder = Not m_displayBorder

        ' Update check on menu.
        Dim menuItem As MenuItem = CType(Me.ContextMenu.Items(0), Windows.Controls.MenuItem)
        menuItem.IsChecked = m_displayBorder

        Me.InvalidateVisual()
    End Sub

#End If

#Region " diagram updates "


    ''' <summary>
    ''' Reset all of the data associated with the diagram.
    ''' </summary>
    Private Sub Clear()
        For Each row In m_rows
            row.Clear()
            Me.RemoveVisualChild(row)
        Next
        m_rows.Clear()
        m_logic.Clear()
    End Sub

    ''' <summary>
    ''' Populate the diagram. Update the diagram and hide all non-primary nodes.
    ''' Then pause, and finish the populate by fading in the new nodes.
    ''' </summary>
    Private Sub Populate()
        ' Set flag to ignore future updates until complete.
        m_populating = True

        ' Update the nodes in the diagram.
        UpdateDiagram()

        ' First hide all of the nodes except the primary node.
        For Each connector In m_logic.ItemLookup.Values
            If Not connector.Node.Item Is m_logic.ItemWrapperItems.Current Then
                connector.Node.Visibility = Visibility.Hidden
            End If
        Next

        ' Required to update (hide) the connector lines.            
        Me.InvalidateVisual()
        Me.InvalidateArrange()
        Me.InvalidateMeasure()

        ' Pause before displaying the new nodes.
        m_animationTimer.Interval = Helper.GetAnimationDuration(AnimationPauseDuration)
        AddHandler m_animationTimer.Tick, New EventHandler(AddressOf OnAnimationTimer)
        m_animationTimer.IsEnabled = True

        ' Let other controls know the diagram has been repopulated.
        OnDiagramPopulated()
    End Sub


    ''' <summary>
    ''' The animation pause timer is complete, finish populating the diagram.
    ''' </summary>
    Sub OnAnimationTimer(ByVal sender As Object, ByVal e As EventArgs)
        ' Turn off the timer.
        m_animationTimer.IsEnabled = False

        ' Fade each node into view.
        For Each connector In m_logic.ItemLookup.Values
            If Not connector.Node.Visibility = Visibility.Visible Then
                connector.Node.Visibility = Visibility.Visible
                connector.Node.BeginAnimation(OpacityProperty, New DoubleAnimation(0, 1, Helper.GetAnimationDuration(NodeFadeInDuration)))
            End If
        Next

        ' Redraw connector lines.
        Me.InvalidateVisual()

        m_populating = False
    End Sub

    ''' <summary>
    ''' Reset the diagram with the nodes. Me is accomplished by creating a series of rows.
    ''' Each row contains a series of groups, and each group contains the nodes. The elements 
    ''' are not laid out at Me time. Also creates the connections between the nodes.
    ''' </summary>
    Private Sub UpdateDiagram()
        ' Necessary for Blend.
        If m_logic.ItemWrapperItems Is Nothing Then
            Return
        End If

        ' First reset everything.
        Clear()

        ' Nothing to draw if there is not a primary Item.
        If m_logic.ItemWrapperItems.Current Is Nothing Then
            Return
        End If

        ' Primary row.
        Dim primaryItem As Item = m_logic.ItemWrapperItems.Current
        Dim primaryRow As DiagramRow = m_logic.CreatePrimaryRow(primaryItem, 1.0, RelatedMultiplier)
        primaryRow.GroupSpace = PrimaryRowGroupSpace
        AddRow(primaryRow)

        ' Create as many rows as possible until exceed the max node limit.
        ' Switch between child and parent rows to prevent only creating
        ' child or parents rows (want to create as many of each as possible).
        Dim nodeCount As Integer = Me.NodeCount

        ' The scale values of future generations, Me makes the nodes
        ' in each row slightly smaller.
        Dim nodeScale As Double = 1.0

        Dim childRow As DiagramRow = primaryRow
        Dim parentRow As DiagramRow = primaryRow


        While (nodeCount < MaximumNodes AndAlso (Not childRow Is Nothing Or Not parentRow Is Nothing))
            ' Child Row.
            If Not childRow Is Nothing Then
                childRow = AddChildRow(childRow)
            End If
            ' Parent row.
            If Not parentRow Is Nothing Then
                nodeScale *= GenerationMultiplier
                parentRow = AddParentRow(parentRow, nodeScale)
            End If
            ' See if reached node limit yet.                                       
            nodeCount = Me.NodeCount
        End While

        ' Raise event so others know the diagram was updated.
        OnDiagramUpdated()
        ' Animate the new Item (optional, might not be any new Items).
        AnimateNewItem()
    End Sub

    ''' <summary>
    ''' Add a child row to the diagram.
    ''' </summary>
    Private Function AddChildRow(ByVal row As DiagramRow) As DiagramRow
        ' Get list of children for the current row.
        Dim children As List(Of Item) = m_logic.GetChildren(row)
        If children.Count = 0 Then
            Return Nothing
        End If

        ' Add bottom space to existing row.
        row.Margin = New Thickness(0, 0, 0, RowSpace)
        ' Add another row.
        Dim childRow As DiagramRow = m_logic.CreateChildrenRow(children, 1.0, RelatedMultiplier)
        childRow.GroupSpace = ChildRowGroupSpace
        AddRow(childRow)
        Return childRow
    End Function


    ''' <summary>
    ''' Add a parent row to the diagram.
    ''' </summary>
    Private Function AddParentRow(ByVal row As DiagramRow, ByVal nodeScale As Double) As DiagramRow
        ' Get list of parents for the current row.
        Dim parents As Collection(Of Item) = m_logic.GetParents(row)
        If parents.Count = 0 Then
            Return Nothing
        End If
        ' Add another row.
        Dim parentRow As DiagramRow = m_logic.CreateParentRow(parents, nodeScale, nodeScale * RelatedMultiplier)
        parentRow.Margin = New Thickness(0, 0, 0, RowSpace)
        parentRow.GroupSpace = ParentRowGroupSpace
        InsertRow(parentRow)
        Return parentRow
    End Function

    ''' <summary>
    ''' Add a row to the visual tree.
    ''' </summary>
    Private Sub AddRow(ByVal row As DiagramRow)
        If Not row Is Nothing And row.NodeCount > 0 Then
            Me.AddVisualChild(row)
            m_rows.Add(row)
        End If
    End Sub


    ''' <summary>
    ''' Insert a row in the visual tree.
    ''' </summary>
    Private Sub InsertRow(ByVal row As DiagramRow)
        If Not row Is Nothing And row.NodeCount > 0 Then
            Me.AddVisualChild(row)
            m_rows.Insert(0, row)
        End If
    End Sub

#End Region

    ''' <summary>
    ''' Called when the current Item in the main Items collection changes.
    ''' Me means the diagram should be updated based on the new selected Item.
    ''' </summary>
    Private Sub OnFamilyCurrentChanged(ByVal sender As Object, ByVal e As EventArgs)
        ' Save the bounds for the current primary Item, Me 
        ' is required later when animating the diagram.
        m_selectedNodeBounds = m_logic.GetNodeBounds(m_logic.ItemWrapperItems.Current)
        ' Repopulate the diagram.
        Populate()
    End Sub


    ''' <summary>
    ''' Called when data changed in the main Items collection. Me can be
    ''' a new node added to the collection, updated Item details, and 
    ''' updated relationship data.
    ''' </summary>
    Private Sub OnFamilyContentChanged(ByVal sender As Object, ByVal e As ContentChangedEventArgs)
        ' Ignore if currently repopulating the diagram.
        If m_populating Then
            Return
        End If

        ' Save the Item that is being added to the diagram.
        ' Me is optional and can be null.
        m_newItem = e.NewItem

        ' Redraw the diagram.
        UpdateDiagram()
        Me.InvalidateMeasure()
        Me.InvalidateArrange()
        Me.InvalidateVisual()
    End Sub

    ''' <summary>
    ''' A node was clicked, make that node the primary node. 
    ''' </summary>
    Private Sub OnNodeClick(ByVal sender As Object, ByVal e As RoutedEventArgs)
        ' Get the node that was clicked.
        Dim node As DiagramNode = CType(sender, DiagramNode)
        If Not node Is Nothing Then
            ' Make it the primary node. Me raises the CurrentChanged
            ' event, which repopulates the diagram.
            m_logic.ItemWrapperItems.Current = node.Item
        End If

    End Sub

    ''' <summary>
    ''' Animate the new Item that was added to the diagram.
    ''' </summary>
    Private Sub AnimateNewItem()
        ' The new Item is optional, can be null.
        If m_newItem Is Nothing Then
            Return
        End If


        ' Get the UI element to animate.                
        Dim node As DiagramNode = m_logic.GeDiagramNode(m_newItem)
        If Not node Is Nothing Then
            ' Create the new Item animation.
            Dim anim As DoubleAnimation = New DoubleAnimation(0, 1, Helper.GetAnimationDuration(NewItemAnimationDuration))
            ' Animate the node.
            Dim transform As New ScaleTransform()
            transform.BeginAnimation(ScaleTransform.ScaleXProperty, anim)
            transform.BeginAnimation(ScaleTransform.ScaleYProperty, anim)
            node.RenderTransform = transform
        End If
        m_newItem = Nothing
    End Sub

    Public ReadOnly Property Logic() As DiagramLogic
        Get
            Return m_logic
        End Get
    End Property

End Class
